Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit b4bedd92fcc8d65daff79200d71f6ebc3d8f0cfa


Parents : 35e2abb
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-13T12:10:18Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-13T12:10:18Z

fix(settings): restore channels from device profiles (#6618)

Changes

16 files changed, 638 insertions(+), 163 deletions(-)


Diff

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSource.kt
index d47fc4ed71..c927871ccd 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSource.kt
@@ -65,7 +65,14 @@ class SwitchingChannelSetDataSource(
/** Replaces all [ChannelSettings] in a single atomic operation. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
- mutate { it.copy(settings = settingsList) }
+ updateChannelSet(settingsList = settingsList, loraConfig = null)
+ }
+
+ /** Atomically updates supplied [ChannelSet] fields while preserving fields omitted by the caller. */
+ suspend fun updateChannelSet(settingsList: List<ChannelSettings>?, loraConfig: Config.LoRaConfig?) {
+ mutate { current ->
+ current.copy(settings = settingsList ?: current.settings, lora_config = loraConfig ?: current.lora_config)
+ }
}
/** Places [channel]'s settings at its index, resizing with blank channels to fill any gap (parity with legacy). */
@@ -82,7 +89,7 @@ class SwitchingChannelSetDataSource(
}
suspend fun setLoraConfig(config: Config.LoRaConfig) {
- mutate { it.copy(lora_config = config) }
+ updateChannelSet(settingsList = null, loraConfig = config)
}
private suspend fun mutate(transform: (ChannelSet) -> ChannelSet) {

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt
index e34a235a43..a32d21b6b7 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/RadioConfigRepositoryImpl.kt
@@ -69,6 +69,10 @@ open class RadioConfigRepositoryImpl(
channelSetDataSource.replaceAllSettings(settingsList)
}
+ override suspend fun updateChannelSet(settingsList: List<ChannelSettings>?, loraConfig: Config.LoRaConfig?) {
+ channelSetDataSource.updateChannelSet(settingsList, loraConfig)
+ }
+
/**
* Updates the [ChannelSettings] list with the provided channel and returns the index of the admin channel after the
* update (if not found, returns 0).

diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSourceTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSourceTest.kt
index 87a8665dec..88eb0a8d2d 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSourceTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/datasource/SwitchingChannelSetDataSourceTest.kt
@@ -26,6 +26,7 @@ import kotlinx.coroutines.test.runTest
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.testing.FakeDatabaseProvider
import org.meshtastic.proto.Channel
+import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import kotlin.test.AfterTest
@@ -75,9 +76,23 @@ class SwitchingChannelSetDataSourceTest {
@Test
fun `replaceAllSettings replaces the whole list`() = runTest(testDispatcher) {
dataSource.updateChannelSettings(secondary(0, "old"))
+ val originalLora = Config.LoRaConfig(channel_num = 4)
+ dataSource.setLoraConfig(originalLora)
dataSource.replaceAllSettings(listOf(ChannelSettings(name = "a"), ChannelSettings(name = "b")))
- assertEquals(listOf("a", "b"), dataSource.channelSetFlow.first().settings.map { it.name })
+ val set = dataSource.channelSetFlow.first()
+ assertEquals(listOf("a", "b"), set.settings.map { it.name })
+ assertEquals(originalLora, set.lora_config)
+ }
+
+ @Test
+ fun `updateChannelSet atomically replaces settings and lora`() = runTest(testDispatcher) {
+ val settings = listOf(ChannelSettings(name = "new"))
+ val lora = Config.LoRaConfig(channel_num = 7)
+
+ dataSource.updateChannelSet(settingsList = settings, loraConfig = lora)
+
+ assertEquals(ChannelSet(settings = settings, lora_config = lora), dataSource.channelSetFlow.first())
}
@Test

diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
index 8c63ebd2b5..7d026ead2e 100644
--- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
@@ -16,10 +16,19 @@
*/
package org.meshtastic.core.domain.usecase.settings
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.util.ChannelReplacementPlan
+import org.meshtastic.core.model.util.MalformedMeshtasticUrlException
+import org.meshtastic.core.model.util.toChannelReplacementPlan
+import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.repository.AdminEditScope
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
+import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
@@ -29,7 +38,11 @@ import org.meshtastic.proto.User
/** Use case for installing a device profile onto a radio. */
@Single
-open class InstallProfileUseCase constructor(private val radioController: RadioController) {
+open class InstallProfileUseCase
+constructor(
+ private val radioController: RadioController,
+ private val radioConfigRepository: RadioConfigRepository,
+) {
/**
* Installs the provided [DeviceProfile] onto the radio at [destNum].
*
@@ -37,15 +50,54 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC
* @param profile The device profile to install.
* @param currentUser The current user configuration of the destination node (to preserve names if not in profile).
*/
- open suspend operator fun invoke(destNum: Int, profile: DeviceProfile, currentUser: User?) {
+ open suspend operator fun invoke(
+ destNum: Int,
+ profile: DeviceProfile,
+ currentUser: User?,
+ currentLoraConfig: Config.LoRaConfig?,
+ isLocal: Boolean,
+ ) {
+ // Decode and validate before opening the radio transaction. A malformed channel URL must not leave an edit
+ // session open or allow the rest of the profile to be partially applied.
+ val channelSet = profile.channel_url?.takeIf { it.isNotBlank() }?.let(::parseProfileChannelSet)
+ val desiredLoraConfig = channelSet?.lora_config ?: profile.config?.lora
+ val replacementPlan =
+ channelSet?.let {
+ try {
+ it.toChannelReplacementPlan(
+ currentSettings = emptyList(),
+ fallbackLoraConfig = profile.config?.lora ?: currentLoraConfig,
+ requirePrimary = true,
+ )
+ } catch (e: IllegalArgumentException) {
+ throw MalformedMeshtasticUrlException("Invalid channel set in device profile", e)
+ }
+ }
+ val loraConfigToWrite = desiredLoraConfig?.takeIf { it != currentLoraConfig }
+
radioController.editSettings(destNum) {
installOwner(profile, currentUser)
installConfig(profile.config)
installFixedPosition(profile.fixed_position)
installModuleConfig(profile.module_config)
+ installChannelsAndLora(replacementPlan, loraConfigToWrite)
+ }
+ if (isLocal && (replacementPlan != null || loraConfigToWrite != null)) {
+ withContext(NonCancellable) {
+ radioConfigRepository.updateChannelSet(
+ settingsList = replacementPlan?.normalizedSettings,
+ loraConfig = loraConfigToWrite,
+ )
+ }
}
}
+ private fun parseProfileChannelSet(url: String): ChannelSet = try {
+ CommonUri.parse(url).toChannelSet()
+ } catch (e: IllegalArgumentException) {
+ throw MalformedMeshtasticUrlException("Invalid channel URL in device profile", e)
+ }
+
// is_licensed is deliberately not installed here: enabling ham mode is a dedicated onboarding flow
// (set_ham_mode β€” rewrites the owner, disables encryption, applies tx power/frequency) that a plain
// set_owner would bypass, leaving the radio flagged licensed without those required side effects.
@@ -70,7 +122,6 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC
lc.power?.let { setConfig(Config(power = it)) }
lc.network?.let { setConfig(Config(network = it)) }
lc.display?.let { setConfig(Config(display = it)) }
- lc.lora?.let { setConfig(Config(lora = it)) }
lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
lc.security?.let { setConfig(Config(security = it)) }
}
@@ -109,4 +160,12 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
}
+
+ private suspend fun AdminEditScope.installChannelsAndLora(
+ replacementPlan: ChannelReplacementPlan?,
+ loraConfig: Config.LoRaConfig?,
+ ) {
+ replacementPlan?.channelWrites?.forEach { setChannel(it) }
+ loraConfig?.let { setConfig(Config(lora = it)) }
+ }
}

diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
index 09a193c229..1fcfcb2e92 100644
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
+++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
@@ -17,7 +17,16 @@
package org.meshtastic.core.domain.usecase.settings
import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.common.log.expectedConditionLabel
+import org.meshtastic.core.model.util.MalformedMeshtasticUrlException
+import org.meshtastic.core.model.util.getChannelUrl
+import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
+import org.meshtastic.core.testing.FakeRadioController.SettingsOperation
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config
import org.meshtastic.proto.Config.BluetoothConfig
import org.meshtastic.proto.Config.DeviceConfig
import org.meshtastic.proto.Config.DisplayConfig
@@ -46,22 +55,26 @@ import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
class InstallProfileUseCaseTest {
private lateinit var radioController: FakeRadioController
+ private lateinit var radioConfigRepository: FakeRadioConfigRepository
private lateinit var useCase: InstallProfileUseCase
@BeforeTest
fun setUp() {
radioController = FakeRadioController()
- useCase = InstallProfileUseCase(radioController)
+ radioConfigRepository = FakeRadioConfigRepository()
+ useCase = InstallProfileUseCase(radioController, radioConfigRepository)
}
@Test
fun `invoke calls begin and commit edit settings`() = runTest {
- useCase(1234, DeviceProfile(), User())
+ useCase(1234, DeviceProfile(), User(), currentLoraConfig = null, isLocal = false)
assertTrue(radioController.editSettingsCalled)
}
@@ -104,7 +117,7 @@ class InstallProfileUseCaseTest {
fixed_position = org.meshtastic.proto.Position(),
)
- useCase(1234, profile, org.meshtastic.proto.User(long_name = "Old"))
+ useCase(1234, profile, org.meshtastic.proto.User(long_name = "Old"), currentLoraConfig = null, isLocal = false)
assertTrue(radioController.editSettingsCalled)
}
@@ -113,9 +126,216 @@ class InstallProfileUseCaseTest {
fun `invoke installs is_unmessagable but never auto-installs is_licensed`() = runTest {
val profile = DeviceProfile(is_unmessagable = true, is_licensed = true)
- useCase(1234, profile, User(long_name = "Old"))
+ useCase(1234, profile, User(long_name = "Old"), currentLoraConfig = null, isLocal = false)
assertEquals(true, radioController.lastSetOwnerUser?.is_unmessagable)
assertEquals(false, radioController.lastSetOwnerUser?.is_licensed)
}
+
+ @Test
+ fun `invoke normalizes channels refreshes local cache and writes URL LoRa once`() = runTest {
+ val primary = ChannelSettings(name = "Node A Primary")
+ val secondary = ChannelSettings(name = "Node A Secondary")
+ val urlLoraConfig =
+ LoRaConfig(
+ use_preset = true,
+ modem_preset = LoRaConfig.ModemPreset.MEDIUM_FAST,
+ region = LoRaConfig.RegionCode.US,
+ )
+ val profileLoraConfig =
+ LoRaConfig(
+ use_preset = true,
+ modem_preset = LoRaConfig.ModemPreset.LONG_SLOW,
+ region = LoRaConfig.RegionCode.EU_868,
+ )
+ val currentLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.ANZ)
+ val oldSettings = listOf(ChannelSettings(name = "Node B Primary"))
+ val cachedLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.EU_433)
+ radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings, lora_config = cachedLoraConfig))
+ val exportedProfile =
+ DeviceProfile(
+ config = org.meshtastic.proto.LocalConfig(lora = profileLoraConfig),
+ channel_url =
+ ChannelSet(
+ settings = listOf(primary, ChannelSettings(), primary, secondary),
+ lora_config = urlLoraConfig,
+ )
+ .getChannelUrl()
+ .toString(),
+ )
+
+ useCase(
+ 4321,
+ exportedProfile,
+ User(long_name = "Node B"),
+ currentLoraConfig = currentLoraConfig,
+ isLocal = true,
+ )
+
+ assertTrue(radioController.editSettingsCalled, "profile install transaction did not run")
+ assertEquals((0..7).toList(), radioController.localChannels.map(Channel::index))
+ assertEquals(
+ listOf(
+ Channel.Role.PRIMARY,
+ Channel.Role.SECONDARY,
+ Channel.Role.DISABLED,
+ Channel.Role.DISABLED,
+ Channel.Role.DISABLED,
+ Channel.Role.DISABLED,
+ Channel.Role.DISABLED,
+ Channel.Role.DISABLED,
+ ),
+ radioController.localChannels.map(Channel::role),
+ )
+ assertEquals(listOf(primary, secondary), radioController.localChannels.take(2).map(Channel::settings))
+ assertEquals(listOf(primary, secondary), radioConfigRepository.currentChannelSet.settings)
+ assertEquals(urlLoraConfig, radioConfigRepository.currentChannelSet.lora_config)
+ assertEquals(listOf(Config(lora = urlLoraConfig)), radioController.localConfigs)
+ assertEquals(
+ radioController.localChannels.map { SettingsOperation.SetChannel(it) } +
+ SettingsOperation.SetConfig(Config(lora = urlLoraConfig)),
+ radioController.settingsOperations,
+ )
+ }
+
+ @Test
+ fun `invoke treats blank channel URL as absent and installs profile LoRa once`() = runTest {
+ val oldSettings = listOf(ChannelSettings(name = "Keep Me"))
+ val currentLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.EU_868)
+ val profileLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.US)
+ val cachedLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.ANZ)
+ radioConfigRepository.setChannelSet(ChannelSet(settings = oldSettings, lora_config = cachedLoraConfig))
+ val profile =
+ DeviceProfile(channel_url = " \t\n", config = org.meshtastic.proto.LocalConfig(lora = profileLoraConfig))
+
+ useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = currentLoraConfig, isLocal = true)
+
+ assertTrue(radioController.editSettingsCalled)
+ assertTrue(radioController.localChannels.isEmpty())
+ assertEquals(listOf(Config(lora = profileLoraConfig)), radioController.localConfigs)
+ assertEquals(
+ listOf(FakeRadioConfigRepository.ChannelSetUpdate(settingsList = null, loraConfig = profileLoraConfig)),
+ radioConfigRepository.channelSetUpdates,
+ )
+ assertEquals(oldSettings, radioConfigRepository.currentChannelSet.settings)
+ assertEquals(profileLoraConfig, radioConfigRepository.currentChannelSet.lora_config)
+ }
+
+ @Test
+ fun `invoke replaces local channels and preserves cached LoRa when no LoRa write is needed`() = runTest {
+ val cachedLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.ANZ)
+ val importedPrimary = ChannelSettings(name = "Imported Primary")
+ radioConfigRepository.setChannelSet(
+ ChannelSet(settings = listOf(ChannelSettings(name = "Old Primary")), lora_config = cachedLoraConfig),
+ )
+ val profile =
+ DeviceProfile(channel_url = ChannelSet(settings = listOf(importedPrimary)).getChannelUrl().toString())
+
+ useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = cachedLoraConfig, isLocal = true)
+
+ assertTrue(radioController.localConfigs.isEmpty())
+ assertEquals(listOf(importedPrimary), radioConfigRepository.currentChannelSet.settings)
+ assertEquals(cachedLoraConfig, radioConfigRepository.currentChannelSet.lora_config)
+ }
+
+ @Test
+ fun `invoke skips redundant LoRa write when desired config is already active`() = runTest {
+ val currentLoraConfig = LoRaConfig(region = LoRaConfig.RegionCode.US)
+ val profile = DeviceProfile(config = org.meshtastic.proto.LocalConfig(lora = currentLoraConfig))
+
+ useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = currentLoraConfig, isLocal = false)
+
+ assertTrue(radioController.localConfigs.isEmpty())
+ }
+
+ @Test
+ fun `invoke does not replace local cache for a remote profile install`() = runTest {
+ val oldSettings = listOf(ChannelSettings(name = "Local Primary"))
+ val remotePrimary = ChannelSettings(name = "Remote Primary")
+ val cachedChannelSet =
+ ChannelSet(settings = oldSettings, lora_config = LoRaConfig(region = LoRaConfig.RegionCode.ANZ))
+ radioConfigRepository.setChannelSet(cachedChannelSet)
+ val profile =
+ DeviceProfile(
+ channel_url =
+ ChannelSet(
+ settings = listOf(remotePrimary),
+ lora_config = LoRaConfig(region = LoRaConfig.RegionCode.US),
+ )
+ .getChannelUrl()
+ .toString(),
+ )
+
+ useCase(4321, profile, User(long_name = "Remote"), currentLoraConfig = null, isLocal = false)
+
+ assertEquals(remotePrimary, radioController.localChannels.first().settings)
+ assertEquals(cachedChannelSet, radioConfigRepository.currentChannelSet)
+ }
+
+ @Test
+ fun `invoke leaves local cache unchanged when a channel write fails`() = runTest {
+ val oldSettings = listOf(ChannelSettings(name = "Local Primary"))
+ val cachedChannelSet =
+ ChannelSet(settings = oldSettings, lora_config = LoRaConfig(region = LoRaConfig.RegionCode.ANZ))
+ radioConfigRepository.setChannelSet(cachedChannelSet)
+ radioController.failChannelWriteAfter = 2
+ val profile =
+ DeviceProfile(
+ channel_url =
+ ChannelSet(
+ settings = listOf(ChannelSettings(name = "Imported Primary")),
+ lora_config = LoRaConfig(region = LoRaConfig.RegionCode.US),
+ )
+ .getChannelUrl()
+ .toString(),
+ )
+
+ assertFailsWith<IllegalStateException> {
+ useCase(4321, profile, User(long_name = "Local"), currentLoraConfig = null, isLocal = true)
+ }
+
+ assertEquals(cachedChannelSet, radioConfigRepository.currentChannelSet)
+ }
+
+ @Test
+ fun `invoke rejects an empty channel set before opening the transaction`() = runTest {
+ val destinationPrimary =
+ Channel(role = Channel.Role.PRIMARY, index = 0, settings = ChannelSettings(name = "Node B Primary"))
+ radioController.localChannels.add(destinationPrimary)
+ val profile =
+ DeviceProfile(
+ long_name = "Must Not Apply",
+ channel_url =
+ ChannelSet(
+ settings = emptyList(),
+ lora_config = LoRaConfig(use_preset = true, region = LoRaConfig.RegionCode.US),
+ )
+ .getChannelUrl()
+ .toString(),
+ )
+
+ assertFailsWith<MalformedMeshtasticUrlException> {
+ useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = null, isLocal = true)
+ }
+
+ assertFalse(radioController.editSettingsCalled)
+ assertEquals(listOf(destinationPrimary), radioController.localChannels)
+ assertTrue(radioController.localConfigs.isEmpty())
+ assertTrue(radioController.settingsOperations.isEmpty())
+ }
+
+ @Test
+ fun `invoke rejects a malformed channel URL before opening the transaction`() = runTest {
+ val profile = DeviceProfile(long_name = "Must Not Apply", channel_url = "https://example.com/not-a-channel")
+
+ val error =
+ assertFailsWith<MalformedMeshtasticUrlException> {
+ useCase(4321, profile, User(long_name = "Node B"), currentLoraConfig = null, isLocal = true)
+ }
+
+ assertFalse(radioController.editSettingsCalled)
+ assertTrue(radioController.localChannels.isEmpty())
+ assertTrue(radioController.localConfigs.isEmpty())
+ assertEquals("malformed-meshtastic-url", error.expectedConditionLabel())
+ }
}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
index 4e8d0891ff..f479f3181c 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSet.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-@file:Suppress("MagicNumber")
+@file:Suppress("MagicNumber", "TooGenericExceptionCaught")
package org.meshtastic.core.model.util
@@ -36,6 +36,7 @@ import org.meshtastic.proto.ModuleSettings
*
* @throws MalformedMeshtasticUrlException when not recognized as a valid Meshtastic URL
*/
+@Suppress("ThrowsCount")
@Throws(MalformedMeshtasticUrlException::class)
fun CommonUri.toChannelSet(): ChannelSet {
val h = host ?: ""
@@ -56,7 +57,12 @@ fun CommonUri.toChannelSet(): ChannelSet {
val fragmentBase64 = fragment!!.substringBefore('?').replace('-', '+').replace('_', '/')
val fragmentBytes =
fragmentBase64.decodeBase64() ?: throw MalformedMeshtasticUrlException("Invalid Base64 in URL fragment")
- val url = ChannelSet.ADAPTER.decode(fragmentBytes)
+ val url =
+ try {
+ ChannelSet.ADAPTER.decode(fragmentBytes)
+ } catch (e: Exception) {
+ throw MalformedMeshtasticUrlException("Failed to decode channel set: ${e::class.simpleName}", e)
+ }
val shouldAdd = fragment?.substringAfter('?', "")?.addParameter() ?: getBooleanQueryParameter("add", false)
return if (shouldAdd) url.copy(lora_config = null) else url

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSetReplacement.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSetReplacement.kt
new file mode 100644
index 0000000000..c3442a8413
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ChannelSetReplacement.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model.util
+
+import okio.ByteString
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config
+import org.meshtastic.core.model.Channel as ModelChannel
+
+/** Firmware channel files expose eight slots: one primary plus up to seven secondary channels. */
+const val CHANNEL_REPLACEMENT_SLOT_COUNT = 8
+
+/** A normalized authoritative channel set and the complete radio writes needed to materialize it. */
+data class ChannelReplacementPlan(val normalizedSettings: List<ChannelSettings>, val channelWrites: List<Channel>)
+
+/**
+ * Produces the canonical authoritative replacement plan used by every full channel-set import path.
+ *
+ * Normalization runs before the firmware-slot bound is checked, allowing padded exports to shed blank or duplicate
+ * secondaries before validation. [requirePrimary] lets profile installation reject an empty decoded set while the
+ * explicit QR replacement flow retains its existing ability to clear every slot.
+ *
+ * @param currentSettings The destination's current settings. Only the size is used when deciding which trailing slots
+ * must be disabled.
+ * @param fallbackLoraConfig LoRa config used for semantic identity when this channel set does not carry one.
+ * @param requirePrimary Whether an empty normalized set must be rejected.
+ */
+fun ChannelSet.toChannelReplacementPlan(
+ currentSettings: List<ChannelSettings>,
+ fallbackLoraConfig: Config.LoRaConfig?,
+ requirePrimary: Boolean = false,
+): ChannelReplacementPlan {
+ val normalizedSettings = normalizeReplacementSettings(settings, lora_config ?: fallbackLoraConfig)
+ require(!requirePrimary || normalizedSettings.isNotEmpty()) {
+ "Imported channel set must contain a primary channel"
+ }
+ require(normalizedSettings.size <= CHANNEL_REPLACEMENT_SLOT_COUNT) {
+ "Imported channel set exceeds supported channel slot count"
+ }
+ return ChannelReplacementPlan(
+ normalizedSettings = normalizedSettings,
+ channelWrites =
+ getChannelReplacementList(
+ new = normalizedSettings,
+ currentSettings = currentSettings,
+ minimumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT,
+ maximumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT,
+ ),
+ )
+}
+
+/**
+ * Builds an authoritative [Channel] list for a full replacement. Every position in [new] is emitted, and any trailing
+ * positions beyond [new]'s range are emitted as disabled so the radio stops using them.
+ *
+ * Unlike a diff, this never skips settings already present in [currentSettings]. The imported set is authoritative, and
+ * stale local cache entries must not suppress a radio write.
+ */
+fun getChannelReplacementList(
+ new: List<ChannelSettings>,
+ currentSettings: List<ChannelSettings>,
+ minimumSlotCount: Int = 0,
+ maximumSlotCount: Int = Int.MAX_VALUE,
+): List<Channel> = buildList {
+ require(minimumSlotCount <= maximumSlotCount) { "minimumSlotCount must be <= maximumSlotCount" }
+ val minimumLastIndex = minimumSlotCount.coerceAtLeast(0) - 1
+ val maximumLastIndex = maximumSlotCount.coerceAtLeast(0) - 1
+ val endIndex = maxOf(currentSettings.lastIndex, new.lastIndex, minimumLastIndex).coerceAtMost(maximumLastIndex)
+ if (endIndex < 0) return@buildList
+ for (index in 0..endIndex) {
+ add(
+ Channel(
+ role =
+ when (index) {
+ 0 -> if (new.isEmpty()) Channel.Role.DISABLED else Channel.Role.PRIMARY
+ in 1..new.lastIndex -> Channel.Role.SECONDARY
+ else -> Channel.Role.DISABLED
+ },
+ index = index,
+ settings = new.getOrNull(index) ?: ChannelSettings(),
+ ),
+ )
+ }
+}
+
+/**
+ * Normalizes replacement settings so firmware only materializes real, distinct channels.
+ *
+ * Slot zero is preserved as-is. Blank placeholder secondaries and semantic duplicates under [loraConfig] are removed,
+ * and the remaining secondaries compact into sequential slots.
+ */
+fun normalizeReplacementSettings(
+ settings: List<ChannelSettings>,
+ loraConfig: Config.LoRaConfig?,
+): List<ChannelSettings> {
+ if (settings.size <= 1) return settings
+ val effectiveLora = loraConfig ?: Config.LoRaConfig()
+ val primary = settings.first()
+ val seen = mutableSetOf<ChannelIdentity>()
+ if (!primary.isChannelPlaceholder()) {
+ seen.add(primary.channelIdentity(effectiveLora))
+ }
+ val compact = mutableListOf(primary)
+ for (index in 1..settings.lastIndex) {
+ val candidate = settings[index]
+ val identity = if (candidate.isChannelPlaceholder()) null else candidate.channelIdentity(effectiveLora)
+ if (identity != null && seen.add(identity)) compact.add(candidate)
+ }
+ return compact
+}
+
+/** True when these settings carry no name and no PSK, making them padding rather than an intended channel. */
+fun ChannelSettings.isChannelPlaceholder(): Boolean = name.isNullOrBlank() && psk.size == 0
+
+/** Semantic channel identity based on the effective name and effective PSK. */
+data class ChannelIdentity(val name: String, val psk: ByteString) {
+ // Never expose an effective PSK through diagnostics or an auto-generated data-class toString.
+ override fun toString(): String = "ChannelIdentity(name=$name, psk=<redacted>)"
+}
+
+/** Resolves this setting's semantic identity under [loraConfig]. */
+fun ChannelSettings.channelIdentity(loraConfig: Config.LoRaConfig): ChannelIdentity {
+ val channel = ModelChannel(settings = this, loraConfig = loraConfig)
+ return ChannelIdentity(name = channel.name, psk = channel.psk)
+}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt
index 56f74c146b..040611ad7b 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MalformedMeshtasticUrlException.kt
@@ -16,5 +16,11 @@
*/
package org.meshtastic.core.model.util
+import org.meshtastic.core.common.log.ExpectedCondition
+
/** Exception thrown when a Meshtastic URL cannot be parsed. */
-class MalformedMeshtasticUrlException(message: String) : Exception(message)
+class MalformedMeshtasticUrlException(message: String, cause: Throwable? = null) :
+ Exception(message, cause),
+ ExpectedCondition {
+ override val expectedConditionLabel: String = "malformed-meshtastic-url"
+}

diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetReplacementTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetReplacementTest.kt
new file mode 100644
index 0000000000..c270f9f740
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ChannelSetReplacementTest.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model.util
+
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class ChannelSetReplacementTest {
+
+ @Test
+ fun `replacement plan normalizes padding and duplicates before slot validation`() {
+ val primary = ChannelSettings(name = "Primary")
+ val uniqueSecondaries = (1..7).map { ChannelSettings(name = "Secondary $it") }
+ val rawSettings = listOf(primary, ChannelSettings(), primary) + uniqueSecondaries
+
+ val plan =
+ ChannelSet(settings = rawSettings)
+ .toChannelReplacementPlan(
+ currentSettings = emptyList(),
+ fallbackLoraConfig = null,
+ requirePrimary = true,
+ )
+
+ assertEquals(listOf(primary) + uniqueSecondaries, plan.normalizedSettings)
+ assertEquals((0..7).toList(), plan.channelWrites.map(Channel::index))
+ assertEquals(Channel.Role.PRIMARY, plan.channelWrites.first().role)
+ assertEquals(List(7) { Channel.Role.SECONDARY }, plan.channelWrites.drop(1).map(Channel::role))
+ }
+
+ @Test
+ fun `replacement plan rejects empty profile channel set before producing writes`() {
+ assertFailsWith<IllegalArgumentException> {
+ ChannelSet(settings = emptyList())
+ .toChannelReplacementPlan(
+ currentSettings = listOf(ChannelSettings(name = "Existing")),
+ fallbackLoraConfig = null,
+ requirePrimary = true,
+ )
+ }
+ }
+}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt
index 67be2a8230..bff29300fb 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt
@@ -40,6 +40,14 @@ interface RadioConfigRepository {
/** Replaces the [ChannelSettings] list with a new [settingsList]. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>)
+ /**
+ * Atomically updates the cached channel set. A null argument preserves that field's current value.
+ *
+ * This is used after a committed radio transaction when channel settings and LoRa configuration must become visible
+ * together.
+ */
+ suspend fun updateChannelSet(settingsList: List<ChannelSettings>?, loraConfig: Config.LoRaConfig?)
+
/** Updates the [ChannelSettings] list with the provided channel. */
suspend fun updateChannelSettings(channel: Channel)

diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt
index 7550d3dbb8..c8197efefa 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioConfigRepository.kt
@@ -41,6 +41,8 @@ class FakeRadioConfigRepository :
BaseFake(),
RadioConfigRepository {
+ data class ChannelSetUpdate(val settingsList: List<ChannelSettings>?, val loraConfig: Config.LoRaConfig?)
+
private val channelSetBacking = mutableStateFlow(ChannelSet())
override val channelSetFlow: Flow<ChannelSet> = channelSetBacking
@@ -93,10 +95,14 @@ class FakeRadioConfigRepository :
var lastSetModuleConfig: ModuleConfig? = null
private set
+ /** Arguments supplied to [updateChannelSet], in call order. */
+ val channelSetUpdates = mutableListOf<ChannelSetUpdate>()
+
init {
registerResetAction {
lastSetLocalConfig = null
lastSetModuleConfig = null
+ channelSetUpdates.clear()
}
}
@@ -105,7 +111,14 @@ class FakeRadioConfigRepository :
}
override suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
- channelSetBacking.value = channelSetBacking.value.copy(settings = settingsList)
+ updateChannelSet(settingsList = settingsList, loraConfig = null)
+ }
+
+ override suspend fun updateChannelSet(settingsList: List<ChannelSettings>?, loraConfig: Config.LoRaConfig?) {
+ channelSetUpdates += ChannelSetUpdate(settingsList, loraConfig)
+ val current = channelSetBacking.value
+ channelSetBacking.value =
+ current.copy(settings = settingsList ?: current.settings, lora_config = loraConfig ?: current.lora_config)
}
override suspend fun updateChannelSettings(channel: Channel) {

diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
index 6668f94655..8c97f71746 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
@@ -37,6 +37,12 @@ class FakeRadioController :
BaseFake(),
RadioController {
+ sealed interface SettingsOperation {
+ data class SetConfig(val config: Config) : SettingsOperation
+
+ data class SetChannel(val channel: Channel) : SettingsOperation
+ }
+
/** Canonical app-level connection state, mirroring [ServiceRepository][connectionState] semantics. */
private val _connectionState = mutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
@@ -56,6 +62,9 @@ class FakeRadioController :
/** Every [setLocalChannel] call, in order. */
val localChannels = mutableListOf<Channel>()
+ /** Every config and channel write, in their shared call order. */
+ val settingsOperations = mutableListOf<SettingsOperation>()
+
var throwOnSend: Boolean = false
/** When true, [setLocalConfig] throws β€” simulates the radio link dropping mid config write. */
@@ -86,6 +95,7 @@ class FakeRadioController :
sentSharedContacts.clear()
localConfigs.clear()
localChannels.clear()
+ settingsOperations.clear()
throwOnSend = false
throwOnSetLocalConfig = false
failChannelWriteAfter = null
@@ -130,10 +140,12 @@ class FakeRadioController :
override suspend fun setLocalConfig(config: Config) {
if (throwOnSetLocalConfig) error("Fake local config write failure")
localConfigs.add(config)
+ settingsOperations.add(SettingsOperation.SetConfig(config))
}
override suspend fun setLocalChannel(channel: Channel) {
localChannels.add(channel)
+ settingsOperations.add(SettingsOperation.SetChannel(channel))
}
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
@@ -144,6 +156,7 @@ class FakeRadioController :
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
localConfigs.add(config)
+ settingsOperations.add(SettingsOperation.SetConfig(config))
}
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {}
@@ -151,6 +164,7 @@ class FakeRadioController :
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
failChannelWriteAfter?.let { if (localChannels.size >= it) error("Fake channel write failure") }
localChannels.add(channel)
+ settingsOperations.add(SettingsOperation.SetChannel(channel))
}
override suspend fun setFixedPosition(destNum: Int, position: Position) {}

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
index 909f27da37..ecfb76aba8 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
@@ -21,10 +21,12 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
-import okio.ByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.util.channelIdentity
+import org.meshtastic.core.model.util.isChannelPlaceholder
+import org.meshtastic.core.model.util.toChannelReplacementPlan
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
@@ -36,13 +38,9 @@ import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Position
import kotlin.time.Duration.Companion.days
-import org.meshtastic.core.model.Channel as ModelChannel
private const val SECONDS_TO_MILLIS = 1000L
-// Firmware channel files expose eight slots: one primary plus up to seven secondary channels.
-private const val CHANNEL_REPLACEMENT_SLOT_COUNT = 8
-
@Composable
fun Position.formatPositionTime(): String {
val currentTime = nowMillis
@@ -93,125 +91,27 @@ fun getChannelList(new: List<ChannelSettings>, old: List<ChannelSettings>): List
}
}
-/**
- * Builds an authoritative [Channel] list for a full REPLACE import. Every position in [new] is emitted (PRIMARY at
- * index 0, SECONDARY for 1..new.lastIndex) and any trailing positions beyond [new]'s range are emitted as DISABLED so
- * the radio stops using them.
- *
- * Unlike [getChannelList], this does NOT skip positions where `currentSettings[i] == new[i]`: the imported set is
- * authoritative, the local cache must not gate the writes, and silent diff-skips during REPLACE were the source of
- * stale channels.
- *
- * [currentSettings] is consulted only for its size (to determine trailing DISABLED writes); its values are never
- * compared against [new]. Callers should read it from `radioConfigRepository.channelSetFlow.first().settings`, not from
- * a `stateInWhileSubscribed` StateFlow's `.value` β€” the StateFlow placeholder window can return an empty list and
- * suppress trailing DISABLED writes.
- *
- * Edge case: if [new] is empty, every emitted slot (including index 0) is DISABLED rather than wrongly promoting an
- * empty [ChannelSettings] to PRIMARY.
- *
- * @param new The imported [ChannelSettings] list. Every index becomes a write to the radio.
- * @param currentSettings The current [ChannelSettings] list. Only its size is used; trailing indices past [new] become
- * DISABLED writes so leftover slots are cleared.
- * @param minimumSlotCount The minimum slot count to emit. Full replacement callers can use this to disable firmware
- * slots even when the local cache is stale or shorter than the radio's actual channel list.
- * @param maximumSlotCount The maximum slot count to emit. Full replacement callers use this to avoid unsupported
- * firmware channel indices even if an imported or cached list is longer than expected.
- * @return A [Channel] list covering every slot the radio needs written to materialize [new] and clear leftover slots.
- */
-fun getChannelReplacementList(
- new: List<ChannelSettings>,
- currentSettings: List<ChannelSettings>,
- minimumSlotCount: Int = 0,
- maximumSlotCount: Int = Int.MAX_VALUE,
-): List<Channel> = buildList {
- require(minimumSlotCount <= maximumSlotCount) { "minimumSlotCount must be <= maximumSlotCount" }
- val minimumLastIndex = minimumSlotCount.coerceAtLeast(0) - 1
- val maximumLastIndex = maximumSlotCount.coerceAtLeast(0) - 1
- val endIndex = maxOf(currentSettings.lastIndex, new.lastIndex, minimumLastIndex).coerceAtMost(maximumLastIndex)
- if (endIndex < 0) return@buildList
- for (i in 0..endIndex) {
- add(
- Channel(
- role =
- when (i) {
- // Empty-new is a degenerate import: every slot (including 0) must be DISABLED.
- 0 -> if (new.isEmpty()) Channel.Role.DISABLED else Channel.Role.PRIMARY
-
- in 1..new.lastIndex -> Channel.Role.SECONDARY
-
- else -> Channel.Role.DISABLED
- },
- index = i,
- settings = new.getOrNull(i) ?: ChannelSettings(),
- ),
- )
- }
-}
-
-/**
- * Normalizes an imported REPLACE-mode [ChannelSettings] list so firmware only materializes real, distinct channels.
- *
- * Imported replacement sets can carry blank placeholder secondaries (trailing empty [ChannelSettings] padding) and
- * semantic duplicates (two slots resolving to the same effective channel under the active LoRa preset). Both produce
- * invalid LongFast-looking slots on the radio that cause route failures (`QueueStatus res=6` / `routeErr=6`).
- * - Slot 0 (primary) is always preserved as-is, even if blank (a blank primary is a deliberate disable signal).
- * - A blank placeholder primary does not participate in duplicate tracking.
- * - Blank placeholder secondaries (no name AND no PSK) are dropped.
- * - Semantic duplicates (same effective name + effective PSK as an earlier kept slot) are dropped.
- * - Remaining valid secondaries compact into sequential slots 1..n.
- *
- * @param settings Raw imported settings list.
- * @param loraConfig Active LoRa config used to resolve effective channel identity. Null falls back to defaults.
- * @return Compacted, deduplicated list safe to write to the radio.
- */
-fun normalizeReplacementSettings(
- settings: List<ChannelSettings>,
- loraConfig: Config.LoRaConfig?,
-): List<ChannelSettings> {
- if (settings.size <= 1) return settings
- val effectiveLora = loraConfig ?: Config.LoRaConfig()
- val primary = settings.first()
- val seen = mutableSetOf<ChannelIdentity>()
- if (!primary.isPlaceholder()) {
- seen.add(primary.channelIdentity(effectiveLora))
- }
- val compact = mutableListOf(primary)
- for (index in 1..settings.lastIndex) {
- val candidate = settings[index]
- val identity = if (candidate.isPlaceholder()) null else candidate.channelIdentity(effectiveLora)
- if (identity != null && seen.add(identity)) {
- compact.add(candidate)
- }
- }
- return compact
-}
-
-/** True when a [ChannelSettings] carries no name and no PSK β€” a placeholder, not an intended channel. */
-private fun ChannelSettings.isPlaceholder(): Boolean = name.isNullOrBlank() && psk.size == 0
-
/**
* Imports a [ChannelSet] as an authoritative REPLACE: writes every channel and β€” when present and actually different β€”
* the imported LoRa config, all inside one [RadioController.editLocalSettings] transaction, then replaces the local
* channel cache.
*
* Reads the current LoRa config and channel set from [radioConfigRepository]'s flows (avoiding the StateFlow
- * placeholder window) and builds the authoritative replacement list via [getChannelReplacementList]. The edit-settings
- * transaction defers disk persistence, radio reload/reconfiguration, and reboot until the closing commit, so channels +
- * LoRa land in a single reboot with no per-slot reconfigure to pace against. (Firmware still writes each `set_channel`
- * into its in-memory channel table as it arrives β€” the transaction is not a full staging of channel state β€” but the
- * expensive persist/reload path runs once at commit.) Writing LoRa inside the same session mirrors
- * `InstallProfileUseCase` and is why the old pre/post settle delays are gone: the begin/commit boundary is the settle.
+ * placeholder window) and builds the shared authoritative replacement plan. The edit-settings transaction defers disk
+ * persistence, radio reload/reconfiguration, and reboot until the closing commit, so channels + LoRa land in a single
+ * reboot with no per-slot reconfigure to pace against. (Firmware still writes each `set_channel` into its in-memory
+ * channel table as it arrives β€” the transaction is not a full staging of channel state β€” but the expensive
+ * persist/reload path runs once at commit.) Writing LoRa inside the same session mirrors `InstallProfileUseCase` and is
+ * why the old pre/post settle delays are gone: the begin/commit boundary is the settle.
*
* The local channel cache is commit-shaped: transactional channel writes deliberately do not mirror per slot (see
* `AdminControllerImpl.EditSettingsSession.setChannel`), and this function replaces the cached channel list once, after
- * the session succeeds β€” so an import interrupted before that point leaves the local channel cache untouched. (The
- * imported LoRa config is the one exception: it still writes through the cache-mirroring `setConfig`, so its local
- * cache update is not itself deferred to commit β€” a single trailing write that self-heals on the device's next config
- * re-send. Making `setConfig` transaction-aware is future work.)
+ * the session succeeds β€” so an import interrupted before that point leaves the local channel cache untouched. The
+ * post-commit [RadioConfigRepository.updateChannelSet] call updates the normalized settings and imported LoRa config
+ * together; an import without LoRa preserves the cached LoRa config.
*
- * Imported settings are normalized via [normalizeReplacementSettings] before any write or bounds check, so blank
- * placeholder secondaries and semantic duplicates never reach the radio or the local cache.
+ * Imported settings are normalized before any write or bounds check, so blank placeholder secondaries and semantic
+ * duplicates never reach the radio or the local cache.
*
* @param channelSet The imported [ChannelSet] to apply as a replacement. Its `lora_config`, if present and different
* from the device's current LoRa config, is written inside the same transaction.
@@ -223,32 +123,21 @@ suspend fun importChannelSet(
radioController: RadioController,
radioConfigRepository: RadioConfigRepository,
) {
- // Resolve the LoRa preset used for semantic identity: prefer the imported config, fall back to the device's current
- // local config so duplicate detection stays correct when the import omits lora_config (e.g. a non-default preset).
val currentLoraConfig = radioConfigRepository.localConfigFlow.first().lora
- val identityLoraConfig = channelSet.lora_config ?: currentLoraConfig
- val normalizedSettings = normalizeReplacementSettings(channelSet.settings, identityLoraConfig)
- require(normalizedSettings.size <= CHANNEL_REPLACEMENT_SLOT_COUNT) {
- "Imported channel set exceeds supported channel slot count"
- }
val currentSettings = radioConfigRepository.channelSetFlow.first().settings
- val replacements =
- getChannelReplacementList(
- new = normalizedSettings,
- currentSettings = currentSettings,
- minimumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT,
- maximumSlotCount = CHANNEL_REPLACEMENT_SLOT_COUNT,
- )
+ val replacementPlan =
+ channelSet.toChannelReplacementPlan(currentSettings = currentSettings, fallbackLoraConfig = currentLoraConfig)
// Only write LoRa when the import carries one that actually differs from the device β€” avoids a redundant
// reconfigure.
val importedLoraConfig = channelSet.lora_config?.takeIf { it != currentLoraConfig }
Logger.i {
- "Applying imported channel replacement writes=${replacements.size} " +
- "importedSettings=${channelSet.settings.size} normalizedSettings=${normalizedSettings.size} " +
+ "Applying imported channel replacement writes=${replacementPlan.channelWrites.size} " +
+ "importedSettings=${channelSet.settings.size} " +
+ "normalizedSettings=${replacementPlan.normalizedSettings.size} " +
"writesLora=${importedLoraConfig != null}"
}
radioController.editLocalSettings {
- for (channel in replacements) {
+ for (channel in replacementPlan.channelWrites) {
Logger.i {
"Writing imported channel index=${channel.index} role=${channel.role} " +
"hasName=${channel.settings?.name?.isNotBlank() == true}"
@@ -257,7 +146,12 @@ suspend fun importChannelSet(
}
importedLoraConfig?.let { setConfig(Config(lora = it)) }
}
- withContext(NonCancellable) { radioConfigRepository.replaceAllSettings(normalizedSettings) }
+ withContext(NonCancellable) {
+ radioConfigRepository.updateChannelSet(
+ settingsList = replacementPlan.normalizedSettings,
+ loraConfig = importedLoraConfig,
+ )
+ }
}
/**
@@ -287,7 +181,7 @@ fun getChannelPreviewForAdd(
val previewSelections = MutableList(existing.size) { true }
var remaining = (maxChannels - existing.size).coerceAtLeast(0)
for (channel in incoming) {
- val shouldShow = !channel.isPlaceholder()
+ val shouldShow = !channel.isChannelPlaceholder()
val identity = if (shouldShow) channel.channelIdentity(loraConfig) else null
// Omit blank placeholders and semantic duplicates entirely β€” they are not shown to the user.
if (identity != null && seen.add(identity)) {
@@ -302,16 +196,3 @@ fun getChannelPreviewForAdd(
/** Filtered ADD-mode preview: the visible channel list paired with its default selections (always size-matched). */
data class ChannelAddPreview(val settings: List<ChannelSettings>, val selections: List<Boolean>)
-
-/** Semantic channel identity based on effective name and effective PSK. */
-private data class ChannelIdentity(val name: String, val psk: ByteString) {
- // Redact the effective PSK from auto-generated diagnostics so a cryptographic key never leaks
- // via toString() in exception messages, debug logs, or stack traces.
- override fun toString(): String = "ChannelIdentity(name=$name, psk=<redacted>)"
-}
-
-/** Resolves the [ChannelIdentity] of this [ChannelSettings] under the given [Config.LoRaConfig]. */
-private fun ChannelSettings.channelIdentity(loraConfig: Config.LoRaConfig): ChannelIdentity {
- val channel = ModelChannel(settings = this, loraConfig = loraConfig)
- return ChannelIdentity(name = channel.name, psk = channel.psk)
-}

diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt
index 6e99e3b115..af8b7f0ec5 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt
@@ -18,6 +18,8 @@ package org.meshtastic.core.ui.util
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.model.util.getChannelReplacementList
+import org.meshtastic.core.model.util.normalizeReplacementSettings
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.proto.Channel
@@ -196,6 +198,10 @@ class ProtoExtensionsTest {
),
radioController.localChannels.map { it.role },
)
+ assertEquals(
+ importedSettings + List(6) { ChannelSettings() },
+ radioController.localChannels.map { it.settings ?: error("Channel write omitted settings") },
+ )
assertEquals(importedSettings, radioConfigRepository.currentChannelSet.settings)
}
@@ -357,6 +363,7 @@ class ProtoExtensionsTest {
// LoRa write is the last op in the edit session, with no settle delays around it.
assertEquals(listOf(Config(lora = imported)), radioController.localConfigs)
+ assertEquals(imported, radioConfigRepository.currentChannelSet.lora_config)
}
@Test

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index ea2696719b..4da4940209 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -58,6 +58,7 @@ import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.util.MalformedMeshtasticUrlException
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.HomoglyphPrefs
@@ -77,6 +78,7 @@ import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.cant_shutdown
+import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.key_backup_deleted
import org.meshtastic.core.resources.key_backup_not_found
import org.meshtastic.core.resources.key_backup_restore_failed
@@ -727,7 +729,22 @@ open class RadioConfigViewModel(
fun installProfile(protobuf: DeviceProfile) {
val destNum = destNum ?: destNode.value?.num ?: return
- safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
+ val state = radioConfigState.value
+ val isLocal = this.destNum == null || destNum == myNodeNum
+ safeLaunch(tag = "installProfile") {
+ try {
+ installProfileUseCase(
+ destNum = destNum,
+ profile = protobuf,
+ currentUser = destNode.value?.user,
+ currentLoraConfig = state.radioConfig.lora,
+ isLocal = isLocal,
+ )
+ } catch (_: MalformedMeshtasticUrlException) {
+ Logger.w { "[installProfile] Rejected invalid profile channel URL" }
+ snackbarManager.showSnackbar(message = UiText.Resource(Res.string.channel_invalid).resolve())
+ }
+ }
}
fun clearPacketResponse() {

diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
index 137a98edae..9399f4486f 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
@@ -56,6 +56,7 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.util.MalformedMeshtasticUrlException
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.HomoglyphPrefs
@@ -1019,11 +1020,28 @@ class RadioConfigViewModelTest {
viewModel = createViewModel()
val profile = DeviceProfile()
- everySuspend { installProfileUseCase(any(), any(), any()) } returns Unit
+ everySuspend { installProfileUseCase(any(), any(), any(), any(), any()) } returns Unit
viewModel.installProfile(profile)
- verifySuspend { installProfileUseCase(123, profile, any()) }
+ verifySuspend { installProfileUseCase(123, profile, any(), null, true) }
+ }
+
+ @Test
+ fun `installProfile surfaces malformed channel URL in snackbar`() = runTest {
+ val node = Node(num = 123, user = User(id = "!123"))
+ nodeRepository.setNodes(listOf(node))
+ viewModel = createViewModel()
+ val profile = DeviceProfile(channel_url = "not-a-channel-url")
+ everySuspend { installProfileUseCase(any(), any(), any(), any(), any()) } calls
+ {
+ throw MalformedMeshtasticUrlException("bad profile")
+ }
+
+ viewModel.installProfile(profile)
+ runCurrent()
+
+ verify { snackbarManager.showSnackbar(message = "This Channel URL is invalid and can not be used") }
}
@Test

Served by rngit 1.4.2 - Generated in 0.38s